/*!
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */

import {DirectiveOwner, ParseSourceSpan, TmplAstHostElement} from '@angular/compiler';
import ts from 'typescript';
import {TcbOp} from './base';
import {Context} from './context';
import type {Scope} from './scope';
import {TypeCheckableDirectiveMeta} from '../../api';
import {addExpressionIdentifier, ExpressionIdentifier, markIgnoreDiagnostics} from '../comments';
import {addParseSpanInfo, wrapForDiagnostics} from '../diagnostics';
import {tsCreateVariable} from '../ts_util';
import {unwrapWritableSignal} from './expression';
import {getAnyExpression} from '../expression';
import {CustomFieldType, expandBoundAttributesForField} from './signal_forms';
import {getBoundAttributes, TcbBoundAttribute, TcbDirectiveInput, widenBinding} from './bindings';
import {translateInput} from './inputs';

/**
 * A `TcbOp` which constructs an instance of a directive with types inferred from its inputs. The
 * inputs themselves are not checked here; checking of inputs is achieved in `TcbDirectiveInputsOp`.
 * Any errors reported in this statement are ignored, as the type constructor call is only present
 * for type-inference.
 *
 * When a Directive is generic, it is required that the TCB generates the instance using this method
 * in order to infer the type information correctly.
 *
 * Executing this operation returns a reference to the directive instance variable with its inferred
 * type.
 */
export class TcbDirectiveCtorOp extends TcbOp {
  constructor(
    private tcb: Context,
    private scope: Scope,
    private node: DirectiveOwner,
    private dir: TypeCheckableDirectiveMeta,
    private customControlType: CustomFieldType | null,
  ) {
    super();
  }

  override get optional() {
    // The statement generated by this operation is only used to infer the directive's type and
    // won't report diagnostics by itself, so the operation is marked as optional.
    return true;
  }

  override execute(): ts.Identifier {
    const genericInputs = new Map<string, TcbDirectiveInput>();
    const id = this.tcb.allocateId();
    let boundAttrs: TcbBoundAttribute[];
    let span: ParseSourceSpan;

    if (this.node instanceof TmplAstHostElement) {
      // Host elements can't bind to their own inputs so we don't resolve any.
      boundAttrs = [];
      span = this.node.sourceSpan;
    } else {
      span = this.node.startSourceSpan || this.node.sourceSpan;
      boundAttrs = getBoundAttributes(this.dir, this.node);

      if (this.customControlType !== null) {
        const additionalBindings = expandBoundAttributesForField(
          this.dir,
          this.node,
          this.customControlType,
        );

        if (additionalBindings !== null) {
          boundAttrs.push(...additionalBindings);
        }
      }
    }

    addExpressionIdentifier(id, ExpressionIdentifier.DIRECTIVE);
    addParseSpanInfo(id, span);

    for (const attr of boundAttrs) {
      // Skip text attributes if configured to do so.
      if (!this.tcb.env.config.checkTypeOfAttributes && typeof attr.value === 'string') {
        continue;
      }
      for (const {fieldName, isTwoWayBinding} of attr.inputs) {
        // Skip the field if an attribute has already been bound to it; we can't have a duplicate
        // key in the type constructor call.
        if (genericInputs.has(fieldName)) {
          continue;
        }

        const expression = translateInput(attr.value, this.tcb, this.scope);

        genericInputs.set(fieldName, {
          type: 'binding',
          field: fieldName,
          expression,
          sourceSpan: attr.sourceSpan,
          isTwoWayBinding,
        });
      }
    }

    // Add unset directive inputs for each of the remaining unset fields.
    for (const {classPropertyName} of this.dir.inputs) {
      if (!genericInputs.has(classPropertyName)) {
        genericInputs.set(classPropertyName, {type: 'unset', field: classPropertyName});
      }
    }

    // Call the type constructor of the directive to infer a type, and assign the directive
    // instance.
    const typeCtor = tcbCallTypeCtor(this.dir, this.tcb, Array.from(genericInputs.values()));
    markIgnoreDiagnostics(typeCtor);
    this.scope.addStatement(tsCreateVariable(id, typeCtor));
    return id;
  }

  override circularFallback(): TcbOp {
    return new TcbDirectiveCtorCircularFallbackOp(this.tcb, this.scope, this.dir);
  }
}

/**
 * A `TcbOp` which is used to generate a fallback expression if the inference of a directive type
 * via `TcbDirectiveCtorOp` requires a reference to its own type. This can happen using a template
 * reference:
 *
 * ```html
 * <some-cmp #ref [prop]="ref.foo"></some-cmp>
 * ```
 *
 * In this case, `TcbDirectiveCtorCircularFallbackOp` will add a second inference of the directive
 * type to the type-check block, this time calling the directive's type constructor without any
 * input expressions. This infers the widest possible supertype for the directive, which is used to
 * resolve any recursive references required to infer the real type.
 */
export class TcbDirectiveCtorCircularFallbackOp extends TcbOp {
  constructor(
    private tcb: Context,
    private scope: Scope,
    private dir: TypeCheckableDirectiveMeta,
  ) {
    super();
  }

  override get optional() {
    return false;
  }

  override execute(): ts.Identifier {
    const id = this.tcb.allocateId();
    const typeCtor = this.tcb.env.typeCtorFor(this.dir);
    const circularPlaceholder = ts.factory.createCallExpression(
      typeCtor,
      /* typeArguments */ undefined,
      [ts.factory.createNonNullExpression(ts.factory.createNull())],
    );
    this.scope.addStatement(tsCreateVariable(id, circularPlaceholder));
    return id;
  }
}

/**
 * Call the type constructor of a directive instance on a given template node, inferring a type for
 * the directive instance from any bound inputs.
 */
function tcbCallTypeCtor(
  dir: TypeCheckableDirectiveMeta,
  tcb: Context,
  inputs: TcbDirectiveInput[],
): ts.Expression {
  const typeCtor = tcb.env.typeCtorFor(dir);

  // Construct an array of `ts.PropertyAssignment`s for each of the directive's inputs.
  const members = inputs.map((input) => {
    const propertyName = ts.factory.createStringLiteral(input.field);

    if (input.type === 'binding') {
      // For bound inputs, the property is assigned the binding expression.
      let expr = widenBinding(input.expression, tcb);

      if (input.isTwoWayBinding && tcb.env.config.allowSignalsInTwoWayBindings) {
        expr = unwrapWritableSignal(expr, tcb);
      }

      const assignment = ts.factory.createPropertyAssignment(
        propertyName,
        wrapForDiagnostics(expr),
      );
      addParseSpanInfo(assignment, input.sourceSpan);
      return assignment;
    } else {
      // A type constructor is required to be called with all input properties, so any unset
      // inputs are simply assigned a value of type `any` to ignore them.
      return ts.factory.createPropertyAssignment(propertyName, getAnyExpression());
    }
  });

  // Call the `ngTypeCtor` method on the directive class, with an object literal argument created
  // from the matched inputs.
  return ts.factory.createCallExpression(
    /* expression */ typeCtor,
    /* typeArguments */ undefined,
    /* argumentsArray */ [ts.factory.createObjectLiteralExpression(members)],
  );
}
